Skip to content

never shell-release a borrowed operand in !, catch, and result unwrap_or#593

Merged
kacy merged 1 commit into
mainfrom
borrowed-operand-extraction-uaf
Jul 26, 2026
Merged

never shell-release a borrowed operand in !, catch, and result unwrap_or#593
kacy merged 1 commit into
mainfrom
borrowed-operand-extraction-uaf

Conversation

@kacy

@kacy kacy commented Jul 26, 2026

Copy link
Copy Markdown
Owner

summary

!, catch, and a result's unwrap_or released the operand's tuple shell
unconditionally after pulling the ok payload out. correct for a fresh owned
temp (make()! — move the payload, free the shell), a use-after-free for a
borrowed operand: a T! parameter, or a named result local. the owner — the
function's caller, or the local's own scope cleanup — then frees the same
three-slot block again. the struct freelist recycles the block in place, so
the program prints the right answer while reading and freeing memory it does
not own; only PITH_STRUCT_FREELIST=0 valgrind sees it.

a small classifier, ir_extraction_operand_class, splits the operand the way
ir_optional_subject_is_owned already splits an optional subject: call /
method-call / await result is owned, an ident / field / index is borrowed. a
borrowed operand keeps its shell (the owner holds it and its one payload
count) and the extraction retains the escaping payload so the consumer has its
own count, exactly as a .ok read does. !'s error path propagates a fresh
error tuple rather than the caller's borrowed one. owned operands are
untouched.

this converts the use-after-free into, at worst, a bounded leak — a borrowed
local whose only use is an extraction isn't cascade-eligible yet, so its
payload count drops once the caller-side cascade learns to cover call
arguments (a separate follow-up, now unblocked). a leak never corrupts.

scope

latent: no std function takes a T!/T? parameter, so nothing shipped
triggered this — it is reachable only by user code that unwraps a result
parameter or a named result local.

the optional unwrap_or path already made this split and is left alone: it
still moves a borrowed subject's inner out without a retain, which is correct
and leak-free for the common single extraction that std leans on
(opt.unwrap_or(bytes.empty())). extracting the same optional local twice is
a distinct, rarer pre-existing bug; covering it with a blanket retain
regressed that common single case into a leak, so it's left for a narrower
fix.

measured (PITH_STRUCT_FREELIST=0 valgrind --error-exitcode=99)

case before after
! on a T! param UAF clean
catch on a T! param UAF clean
result unwrap_or on a T! param UAF clean
the same, on a named result local UAF (freelist-masked) clean
opt.unwrap_or(...) on a named optional local (std's shape) clean clean (unchanged)
owned inline make()! / catch / unwrap_or control clean clean

what was tested

make bootstrap-verify green, make green-tests, make memcheck clean under
valgrind including the new case, cargo test --workspace (102). the grpc serve
and connection-reuse tests pass under both backends.
tests/cases/test_borrowed_operand_extraction drives !/catch/unwrap_or
over borrowed parameters and named locals on the ok and error paths with owned
inline operands as the control, and is in MEMCHECK_CASES.

…nwrap_or`

`!`, `catch`, and a result's `unwrap_or` released the operand's tuple shell
unconditionally after pulling the ok payload out. that is correct when the
operand is a fresh owned temp (`make()!` — move the payload out, free the
shell), but a use-after-free when the operand is borrowed: a `T!` parameter,
or a named result local. the operand's owner — the caller of the function, or
the local's own scope cleanup — then frees the same three-slot block again.
the struct freelist recycles the block in place, so the program prints the
right answer while reading and freeing memory it does not own; only
`PITH_STRUCT_FREELIST=0 valgrind` sees it.

a small classifier, ir_extraction_operand_class, splits the operand the way
ir_optional_subject_is_owned already splits an optional subject: a call,
method-call, or await result is owned; an ident, field, or index is borrowed.
for a borrowed operand the extraction now keeps its hands off the shell — the
owner still holds it and the one payload count it carries — and retains the
escaping heap payload so the consumer has its own count, exactly as a `.ok`
read does. `!`'s error path propagates a fresh error tuple rather than handing
the caller's borrowed tuple upward. owned operands are untouched.

this turns the borrowed-operand use-after-free into, at worst, a bounded leak
(a borrowed local whose only use is an extraction is not yet cascade-eligible,
so its payload count is dropped once the caller-side cascade learns to cover
call arguments — a separate follow-up). safety first: a leak never corrupts.

the optional `unwrap_or` path already made this split and is left alone. it
still moves a borrowed subject's inner out without a retain, which is correct
and leak-free for the common single extraction — the shape std leans on
(`opt.unwrap_or(bytes.empty())`). extracting the same optional local twice is
a distinct, rarer pre-existing bug and is not addressed here; covering it with
a blanket retain regressed that common single case into a leak, so it is left
for a narrower fix.

tests/cases/test_borrowed_operand_extraction drives `!`/`catch`/`unwrap_or`
over borrowed parameters and named locals, ok and error paths, with owned
inline operands as the control, and is valgrind-gated in MEMCHECK_CASES.
@kacy
kacy merged commit 6926455 into main Jul 26, 2026
2 checks passed
@kacy
kacy deleted the borrowed-operand-extraction-uaf branch July 26, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant